Skip to content

[WIP][Pro] React 19.2 Partial Prerendering (PPR) — closes #3244#3245

Draft
AbanoubGhadban wants to merge 2 commits into
mainfrom
worktree-ppr-implementation
Draft

[WIP][Pro] React 19.2 Partial Prerendering (PPR) — closes #3244#3245
AbanoubGhadban wants to merge 2 commits into
mainfrom
worktree-ppr-implementation

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

Summary

WIP draft implementing the RFC from #3244. Adds ppr_react_component Pro helper that builds a static HTML shell once (Phase A, prerenderToNodeStream + AbortController), caches it, then streams shell + resume chunks (Phase B, resumeToPipeableStream) on every hit. Components inside <Suspense> boundaries can call usePostpone() to declare themselves dynamic; siblings that resolve before the abort timer land in the cached shell.

End-to-end verified in the Pro dummy app:

Hit TTFB
Cold (first request, prerender + cache write) ~5.9s
Warm (cache HIT, resume only) ~0.16s
Speedup ~36×

Static product names stay identical across requests (cached shell). Dynamic timestamps + cart counts change every request (resume freshness).

What's in this PR

JS — packages/react-on-rails-pro/

  • src/capabilities/proPPR.tsprerenderReactComponentForPPR and resumeReactComponentForPPR. Lazy-loaded React PPR APIs (peer dep is react >= 16; APIs only exist in 19.2+). Runtime checks for AbortController/AsyncLocalStorage/real timers (fails fast if RENDERER_STUB_TIMERS=true).
  • src/postpone.tswithPhase + usePostpone. AsyncLocalStorage-based phase tracking with module-level fallback.
  • src/registerServerComponent/server.tsx — RSC wrappers tagged with Symbol.for('react_on_rails_pro.rsc_component') so PPR refuses RSC components in v1 (RSC + PPR composition is intentionally deferred).
  • src/ReactOnRails.node.ts — registers PPR capability only on the Node SSR entry, not the RSC bundle.

OSS — packages/react-on-rails/

  • src/rscMarker.ts — shared marker (Symbol.for stable across module boundaries).
  • lib/.../render_options.rb — adds :ppr_prerender, :ppr_resume render modes; new html_or_rsc_streaming? predicate so consumers that should NOT engage on PPR resume have a clean way to opt out.
  • lib/.../render_request.rb — delegates the new predicates.

Pro Ruby — react_on_rails_pro/

  • lib/.../ppr.rb — cache-key composition (includes bundle digest, cache_key, prerender timeout, PPR cache version) and runtime support check.
  • lib/.../configuration.rbenable_ppr_support (default false), ppr_prerender_timeout_ms (default 8s).
  • app/helpers/.../ppr_react_component — top-level helper. Hard-validates JS-side response shape (Hash, pprShellHtml key) before caching.
  • Surgical updates to pro_rendering.rb, server_rendering_js_code.rb, js_code_builder.rb so PPR doesn't trip RSC/stream-cache code paths.

Node renderer — packages/react-on-rails-pro-node-renderer/

  • src/worker/vm.tsAbortController, AbortSignal, AsyncLocalStorage injected unconditionally (independent of supportModules). No protocolVersion bump.

Demo & tests

  • 3 dummy-app pages: comprehensive (mixed static + dynamic + nested), fully-static, all-dynamic.
  • Unit test (tests/proPPR.test.tsx) — prerender→resume cycle, fully-static fast path.

Design rounds with codex

The .claude/docs/ppr/design-v[1-5].md files in this PR document 5 iterations of design review with codex before any code was written, plus 2 implementation review rounds (verdict: APPROVE). Notable decisions:

  1. PPR works on the SSR HTML layer, not RSC. Pages using <RSCRoute> keep using stream_react_component.
  2. No new HTTP endpoints. Dispatch via existing /bundles/<hash>/render endpoint with new render_mode. No protocolVersion bump means older renderers aren't broken.
  3. Lazy React imports. Apps on React 16-19.1 can register the capability without crashing — error only surfaces if the user actually calls a PPR helper.
  4. Defense-in-depth phase tracking. Every callback (timer, abort listener, onError, stream events) wrapped with withPhase rather than relying on AsyncLocalStorage's automatic propagation alone.
  5. React.unstable_postpone was removed before stable. usePostpone() is implemented via the abort-signal mechanism (throw never-resolving promise) — same semantics, public-API only.

Known limitations / non-goals for this PR

  • PPR + RSC composition is deferred. Components registered via registerServerComponent are explicitly rejected.
  • First-hit blocks for the prerender timeout (default 8s). A future warmup rake task can move this to build time.
  • Async client components warning. React 19 logs warnings for async function components on the client during hydration. The demo uses async function components for brevity; users with strict console hygiene should use use() + sync components.
  • Docs not yet written. docs/api/ppr.md + migration guide are TODO before merging.

Test plan

  • pnpm --filter react-on-rails-pro type-check clean
  • pnpm --filter react-on-rails-pro build clean
  • pnpm --filter react-on-rails-pro exec jest tests/proPPR.test.tsx (2 tests pass)
  • Dummy app boots with enable_ppr_support = true, ppr_prerender_timeout_ms = 5000
  • http://localhost:3001/ppr_demo — cold 5.9s, warm 0.16s, dynamic content varies, static stays
  • http://localhost:3001/ppr_static_only_demo — fully-static fast path (postponedState null)
  • http://localhost:3001/ppr_all_dynamic_demo — every section postpones
  • Playwright E2E spec (TODO before un-drafting)
  • CI green (TODO — push and observe)
  • Docs page (TODO before un-drafting)
  • CHANGELOG entry (TODO before un-drafting)

Files changed

37 files, +2473/-15. See diff.

🤖 Generated with Claude Code

AbanoubGhadban and others added 2 commits May 7, 2026 17:34
Adds ppr_react_component helper that builds a static shell once (Phase A,
prerenderToNodeStream + AbortController), caches it in Rails.cache, then
streams shell + resume chunks (Phase B, resumeToPipeableStream) on every
hit. Components inside <Suspense> boundaries can call usePostpone() to
declare themselves dynamic; siblings that resolve before the abort timer
land in the cached shell.

Implementation details negotiated with codex over 5 design iterations
(see .claude/docs/ppr/design-v[1-5].md):
  - JS capability (proPPR.ts) + AsyncLocalStorage phase tracking
  - Lazy-loaded React PPR APIs (peer dep is react>=16; APIs only exist in 19.2+)
  - RSC component marker (Symbol.for) so PPR refuses RSC components in v1
  - Surgical streaming? predicate updates: html_or_rsc_streaming? for
    consumers that should NOT engage on PPR resume
  - VM globals: AbortController, AbortSignal, AsyncLocalStorage injected
    unconditionally (independent of supportModules) in node-renderer
  - Cache shape: {shell_html, postponed_state, console_replay_script,
    ppr_version} in Rails.cache; bundle-digest-keyed
  - PPR registered in ReactOnRails.node.ts only; not in RSC bundle

Dummy app demo pages (PPRComprehensiveDemo, PPRStaticOnlyDemo,
PPRAllDynamicDemo) exercise multiple Suspense boundaries with mixed
fast/slow/postponed content, fully-static, and all-dynamic edge cases.

Unit test (tests/proPPR.test.tsx) verifies the prerender→resume cycle:
shell contains static content + postponed-boundary placeholders, resume
fills the holes with per-request data, fully-static page short-circuits.
TypeScript and tests pass clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 issues from implementation review (codex), all addressed:

1. PPR can hang under stub timers — check for real setTimeout/clearTimeout in
   checkPPRRuntimeOrThrow and fail fast with a clear error pointing to
   RENDERER_STUB_TIMERS=false. (proPPR.ts)

2. Corrupt postponedState JSON committed shell before failure — parse +
   validate pprPostponedState BEFORE the first writeChunk. On JSON.parse
   failure, surface as a pre-stream error so Rails can replace the response.
   (proPPR.ts)

3. Resume pre-stream errors ignored throwJsErrors — both pre-shell and
   post-shell catches now honor options.throwJsErrors. (proPPR.ts)

4. Malformed prerender result could cache empty shell — internal_ppr_prerender
   now hard-validates result.is_a?(Hash) and result.key?('pprShellHtml')
   before returning. Failures raise so Rails.cache.fetch's block does NOT
   write. (react_on_rails_pro_helper.rb)

5. Cache key didn't vary on prerender_timeout_ms — different timeouts produce
   different sets of resolved-vs-postponed boundaries, so they need
   independent cache entries. PPR.cache_key now includes the effective
   timeout. (ppr.rb, react_on_rails_pro_helper.rb)

Tests still pass; Rails dummy app still demonstrates 36x speedup
(5.9s cold → 0.16s warm hit) with dynamic timestamps changing per request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81b22c02-5d2b-4ad9-889c-567a7051d242

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-ppr-implementation

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

def ppr_all_dynamic_demo
stream_view_containing_react_components(template: "/pages/ppr_all_dynamic_demo")
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: this clears the entire application cache, not just PPR entries.

Rails.cache.clear evicts session data, fragment caches, and any other cached content sharing the same store. In a shared development environment (or if this route is ever reachable in staging) it becomes a silent DoS against every other cache consumer.

Scope the invalidation to PPR keys instead:

Suggested change
def ppr_demo_clear_cache
# Clear only PPR cache entries by scanning for our namespace prefix.
# Rails.cache.clear would evict unrelated caches (sessions, fragments, etc.).
if Rails.cache.respond_to?(:delete_matched)
Rails.cache.delete_matched(/\Aror_pro_ppr-v/)
else
Rails.logger.warn "[PPR] Cache store does not support delete_matched; skipping selective clear."
end
head :no_content
end

Alternatively, store PPR keys in a dedicated cache store (e.g. :ppr_store) and call .clear on that.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.76 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.76 KB (0%)
react-on-rails/client bundled (brotli) 53.86 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.86 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.71 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.71 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.63 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.63 KB (0%)
registerServerComponent/client bundled (gzip) 127.53 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.53 KB (0%)
registerServerComponent/client bundled (brotli) 61.7 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.7 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.73 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.73 KB (0%)

const onError = (err: unknown) => {
// Expected AbortError during normal abort flow — swallow.
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('aborted') || msg.includes('AbortError')) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile AbortError detection: silently swallows any error whose message contains "aborted".

A legitimate component error like new Error("request aborted by user") or new Error("AbortError: network timeout") will be suppressed here, hiding real bugs during prerender.

Prefer checking the abort signal's state first (it's already in scope), then fall back to the standard DOMException name:

Suggested change
if (msg.includes('aborted') || msg.includes('AbortError')) return;
// Expected abort flow — swallow when the abort signal fired; also match the
// standard DOMException name as a fallback for environments where the error
// doesn't cross the instanceof boundary cleanly.
if (controller.signal.aborted) return;
if (err instanceof Error && err.name === 'AbortError') return;

Comment on lines +353 to +375
} catch (e) {
// POST-shell error (after writeChunk): the shell is already on the wire so we can't
// redirect to a fresh error page. Surface via the chunk pipeline. Honor throwJsErrors so
// tests / strict consumers see the failure rather than a partial render.
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;
if (options.throwJsErrors) {
emitError(error);
} else {
const errorHtmlStream = handleError({ e: error, name: options.name, serverSide: true });
pipeToTransform(errorHtmlStream);
}
}
};

withPhase('resume', () => {
runResume().catch((e: unknown) => {
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;
emitError(error);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential double error emission when throwJsErrors is true.

runResume's own catch block calls emitError(error) when throwJsErrors is true (line 361), which consumes the error. But if emitError itself throws (or if another uncaught exception escapes the try block that the inner catch misses), the outer .catch at line 370 fires and calls emitError a second time on the already-closed/errored stream, which can cause write after end or similar stream errors.

runResume exhaustively handles its own errors, so the outer .catch should only exist as a last-resort safety net—it should not re-emitError unconditionally. Consider:

Suggested change
} catch (e) {
// POST-shell error (after writeChunk): the shell is already on the wire so we can't
// redirect to a fresh error page. Surface via the chunk pipeline. Honor throwJsErrors so
// tests / strict consumers see the failure rather than a partial render.
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;
if (options.throwJsErrors) {
emitError(error);
} else {
const errorHtmlStream = handleError({ e: error, name: options.name, serverSide: true });
pipeToTransform(errorHtmlStream);
}
}
};
withPhase('resume', () => {
runResume().catch((e: unknown) => {
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;
emitError(error);
});
withPhase('resume', () => {
// runResume handles all of its own errors. The outer catch is a last-resort guard
// for unanticipated throws that escape the try/catch inside runResume (e.g., if
// emitError itself throws). Avoid calling emitError again if the stream may already
// be closed.
runResume().catch((e: unknown) => {
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;
// Only emit if the stream hasn't been closed yet; double-emitting causes
// "write after end" errors on the underlying PassThrough.
try { emitError(error); } catch { /* stream already closed */ }
});
});

Comment on lines +110 to +113
const handle = (setT as (cb: () => void, ms: number) => unknown)(() => {}, 0);
// Stubbed timers in the node renderer return undefined.
if (handle === undefined || handle === null) missing.push('setTimeout (real, not stubbed)');
else (clearT as (h: unknown) => void)(handle);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile stub-timer detection: the undefined return value is an undocumented implementation detail.

This check assumes the Pro node renderer's stubbed setTimeout returns undefined. If the stub is ever changed to return 0 or null (both falsy but not undefined/null), real timers would be incorrectly flagged as stubbed. Similarly, a real Node.js Timeout object with a zero timeout fires almost immediately, so the timer scheduled here actually runs — leaking a no-op tick into the event loop.

A more robust approach is to explicitly check for the known RENDERER_STUB_TIMERS environment variable that already guards this behavior, or to treat the check as informational (a warning rather than a hard throw) since the actual failure mode (timer never fires) will be caught by the outer prerender timeout anyway:

Suggested change
const handle = (setT as (cb: () => void, ms: number) => unknown)(() => {}, 0);
// Stubbed timers in the node renderer return undefined.
if (handle === undefined || handle === null) missing.push('setTimeout (real, not stubbed)');
else (clearT as (h: unknown) => void)(handle);
// Check if we're in stub-timer mode by inspecting the renderer env variable that controls it.
// Probing setTimeout's return value is fragile; the env var is the authoritative signal.
if (
typeof process !== 'undefined' &&
(process.env['RENDERER_STUB_TIMERS'] === 'true' || process.env['RENDERER_STUB_TIMERS'] === '1')
) {
missing.push('setTimeout (real, not stubbed — set RENDERER_STUB_TIMERS=false)');
}

Comment on lines +244 to +251
return {
html: shellHtml,
pprShellHtml: shellHtml,
pprPostponedState: postponed ? JSON.stringify(postponed) : null,
consoleReplayScript,
hasErrors: false,
isShellReady: true,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shellHtml is serialized twice in the prerender response.

Both html and pprShellHtml carry the same string. For large pages (marketing pages with thousands of nodes are common PPR candidates) this doubles the JSON payload sent from the node renderer back to Ruby. The node renderer already JSON-stringifies the whole result object, so the duplication isn't free.

internal_ppr_prerender on the Ruby side reads result["pprShellHtml"] explicitly. If you drop html from the prerender return value, the Pro Ruby helper needs a small update—but that's better than sending every shell twice forever.

At a minimum, omit html from the prerender response type and remove the field here:

Suggested change
return {
html: shellHtml,
pprShellHtml: shellHtml,
pprPostponedState: postponed ? JSON.stringify(postponed) : null,
consoleReplayScript,
hasErrors: false,
isShellReady: true,
};
return {
pprShellHtml: shellHtml,
pprPostponedState: postponed ? JSON.stringify(postponed) : null,
consoleReplayScript,
hasErrors: false,
isShellReady: true,
};

Update PPRPrerenderResult accordingly and confirm that no other consumer reads .html on the prerender result.

Comment on lines +62 to +66
let cachedAPIError: Error | null = null;

async function loadPPRReactAPIs() {
if (cachedAPIs) return cachedAPIs;
if (cachedAPIError) throw cachedAPIError;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permanent failure on transient errors: once cachedAPIError is set, every future PPR call fails without retry.

The intent is good (avoid re-resolving dynamic imports repeatedly), but if the first PPR call happens before React 19.2 is fully available (e.g., during a hot-module-replacement cycle in development, or a race at worker startup), the error is cached permanently and the worker must be restarted to recover.

For the success path, the singleton is correct. For failures, consider only caching hard version errors (React too old) while letting transient module-resolution errors retry:

Suggested change
let cachedAPIError: Error | null = null;
async function loadPPRReactAPIs() {
if (cachedAPIs) return cachedAPIs;
if (cachedAPIError) throw cachedAPIError;
let cachedAPIs: {
prerenderToNodeStream: PrerenderToNodeStreamFn;
resumeToPipeableStream: ResumeToPipeableStreamFn;
} | null = null;
// Only cache the error when it's definitively a version mismatch; transient
// resolution failures (e.g. during HMR) can be retried.
let cachedVersionError: Error | null = null;

Then in the catch:

} catch (e) {
  const err = convertToError(e);
  // Permanently cache only version errors; other errors (transient HMR races, etc.) can retry.
  if (err.message.includes('React on Rails Pro PPR requires React')) {
    cachedVersionError = err;
  }
  throw err;
}

import type { Readable } from 'stream';
import { ReadableStream } from 'stream/web';
import { promisify, TextEncoder } from 'util';
import { AsyncLocalStorage } from 'async_hooks';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the node: prefix for built-in module imports.

async_hooks (without prefix) is the legacy form. The canonical modern form is node:async_hooks. The prefixed form is unambiguous (can't be shadowed by a userland package named async_hooks) and is required by some linting rules and bundler configurations.

Suggested change
import { AsyncLocalStorage } from 'async_hooks';
import { AsyncLocalStorage } from 'node:async_hooks';

Comment on lines 61 to 72
render_function_name =
if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
if render_options.ppr_prerender?
"'prerenderReactComponentForPPR'"
elsif render_options.ppr_resume?
"'resumeReactComponentForPPR'"
elsif ReactOnRailsPro.configuration.enable_rsc_support && render_options.html_or_rsc_streaming?
# Select appropriate function based on whether the rendering request is running on server or rsc bundle
# As the same rendering request is used to generate the rsc payload and SSR the component.
"ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'"
else
"'serverRenderReactComponent'"
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PPR render-function dispatch is duplicated verbatim in js_code_builder.rb.

JsCodeBuilder#resolve_render_function_js_expr (lines 116-126 of js_code_builder.rb) contains identical ppr_prerender?/ppr_resume? branches. When a third PPR function is ever added, a developer must update two files. One of these should delegate to the other:

# server_rendering_js_code.rb — delegate to JsCodeBuilder
render_function_name = JsCodeBuilder.new(render_options).resolve_render_function_js_expr(render_options)

Or extract a small shared module. The duplication is currently invisible because both files produce the same string, but it will silently diverge on future changes.

Comment on lines +17 to +19
currentTime: Time.current.iso8601(3),
userName: cookies[:demo_user_name].presence || "Guest",
cartItemCount: rand(0..9),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Demo anti-pattern: request-varying values are passed as top-level props.

currentTime and cartItemCount are evaluated on every Rails request and sent as props to both the prerender phase and the resume phase. In the prerender, the components that read these values call usePostpone(), so the values are correctly ignored during shell construction. But:

  1. These values are transmitted to the node renderer on every prerender call—even on cache hits, because ppr_react_component re-invokes internal_ppr_resume (which injects them into railsContext) on every request. That's fine by design, but the demo makes it look like passing dynamic props to ppr_react_component is the right pattern, when in fact the shell's cache key ("ppr-demo-v1") is fixed and doesn't change with these values.

  2. rand(0..9) in particular is a trap: if a future developer adds a static boundary that reads cartItemCount, they'll get a silently wrong (cached) value.

Consider clarifying the demo comment to make the design contract explicit:

<%# Props here are forwarded to the RESUME phase only. The static shell is cached
    independently of these values. Dynamic components must call usePostpone() to
    ensure they are excluded from the cached shell. %>
<%= ppr_react_component(
      "PPRComprehensiveDemo",
      cache_key: ["ppr-demo-v1", request.host],
      props: {
        currentTime: Time.current.iso8601(3),
        userName: cookies[:demo_user_name].presence || "Guest",
        cartItemCount: rand(0..9),
      },

Comment on lines +84 to +90
ppr_params = if render_options.ppr?
<<-JS
railsContext.pprPhase = #{render_options.ppr_prerender? ? '"prerender"' : '"resume"'};
railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json};
railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json};
railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json};
JS
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pprShellHtml is injected via railsContext even during the prerender phase, where it is unused.

The ppr? guard is true for both :ppr_prerender and :ppr_resume. During prerender, pprShellHtml and pprPostponedState are always nil/null, so they serialize to null and waste bandwidth. More importantly, for the resume phase, the shell HTML (which can be hundreds of KB for complex pages) is serialized into the JS code string that is sent as the HTTP request body to the node renderer—effectively transmitting the cached shell back to the renderer that generated it in the first place.

Consider splitting this block and only injecting pprShellHtml/pprPostponedState on resume:

Suggested change
ppr_params = if render_options.ppr?
<<-JS
railsContext.pprPhase = #{render_options.ppr_prerender? ? '"prerender"' : '"resume"'};
railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json};
railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json};
railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json};
JS
ppr_params = if render_options.ppr?
phase = render_options.ppr_prerender? ? '"prerender"' : '"resume"'
resume_fields = if render_options.ppr_resume?
<<~JS
railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json};
railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json};
JS
else
""
end
<<~JS
railsContext.pprPhase = #{phase};
railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json};
#{resume_fields}
JS
else
""
end

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 7, 2026

Code Review — PPR Implementation (WIP)

This is a well-architected draft with a clear design history and good defense-in-depth thinking. The core flow (prerender → cache → resume-stream) is sound, and the five design-review rounds show the hard problems have been thought through carefully. The notes below are about concrete bugs and a few patterns that will cause pain in production.


Critical / Must-Fix

1. ppr_demo_clear_cache wipes the entire application cache (inline comment on pages_controller.rb line 112)

Rails.cache.clear evicts sessions, fragment caches, and everything else sharing the store. Scope it to PPR keys only (delete_matched on the ror_pro_ppr-v prefix, or a dedicated cache store). Even in a development dummy app this is a footgun that will confuse anyone trying to debug a regression in an unrelated feature.

2. AbortError detection silently swallows unrelated errors (inline comment on proPPR.ts line 230)

msg.includes('aborted') || msg.includes('AbortError') matches any component error whose message happens to contain those words. Use controller.signal.aborted (already in scope) as the first check; fall back to err.name === 'AbortError' for cross-realm cases.

3. Potential double emitError emission (inline comment on proPPR.ts lines 353–375)

runResume's own catch calls emitError(error) when throwJsErrors is true. If that call itself throws, the outer .catch at line 370 fires a second emitError on an already-closed stream, producing a write after end crash. Wrap the outer .catch call in a try/catch.


Important

4. pprShellHtml is sent twice per prerender response (inline comment on proPPR.ts lines 244–251)

Both html and pprShellHtml carry identical strings. The Ruby side reads pprShellHtml exclusively, so html is pure waste. For large PPR pages (marketing pages are typical PPR candidates) this can double the renderer-to-Rails response size.

5. Shell HTML is injected into railsContext on every resume (inline comment on server_rendering_js_code.rb lines 84–90)

The cached shell is serialized into the JS code string sent as the HTTP request body to the node renderer. On a busy site with 50 KB shells, that's 50 KB of HTTP overhead per request. Only inject pprShellHtml/pprPostponedState when render_options.ppr_resume? is true; skip them entirely on prerender.

6. PPR function dispatch is duplicated in two Ruby files (inline comment on server_rendering_js_code.rb lines 61–72)

resolve_render_function_js_expr in js_code_builder.rb and render_function_name in server_rendering_js_code.rb contain identical ppr_prerender?/ppr_resume? branches. One should delegate to the other or both should delegate to a shared helper in render_options.

7. Transient load errors permanently poison the API cache (inline comment on proPPR.ts lines 62–66)

Once cachedAPIError is set (e.g., by an HMR race at worker startup), every subsequent PPR call fails until the worker process is restarted. Only cache hard version-mismatch errors permanently; let transient module-resolution failures retry.

8. Fragile stub-timer detection (inline comment on proPPR.ts lines 110–113)

Detecting stubbed timers by checking whether setTimeout returns undefined relies on undocumented behavior of the node renderer's stub implementation. If the stub changes to return 0 or any other falsy value, real timers will be incorrectly rejected. Use the RENDERER_STUB_TIMERS env var (which already controls this behavior) as the authoritative signal.

9. node:async_hooks should use the canonical prefix (inline comment on vm.ts line 14)

The legacy async_hooks specifier can be shadowed by a userland package of the same name. Use node:async_hooks.


Minor / Before Un-drafting

Demo: dynamic props look like they're part of the cache contract (inline comment on ppr_demo.html.erb lines 17–19)

cartItemCount: rand(0..9) and currentTime: Time.current.iso8601(3) are evaluated every request and forwarded to both phases. The demo is correct (the components that read them call usePostpone), but newcomers reading the demo will not understand that the shell's cache key is fixed and independent of these prop values. A brief comment explaining the prerender/resume prop split will save a lot of confusion in docs and support.

Missing test coverage — before un-drafting the test suite should cover:

  • Timeout fires and prerender still produces a valid (possibly smaller) shell
  • Prerender hasErrors: true → cache not written, no shell served
  • Corrupted pprPostponedState in cache → clean error surfaced to Rails
  • RSC component passed to ppr_react_component → clear error, no crash

ppr_prerender_timeout_ms is not validated on the Ruby side — a string or negative number reaches the JS as-is and produces a confusing failure instead of an early error.


What's Working Well

  • The lazy React API import pattern is exactly right — apps on React 16-19.1 won't crash on module load.
  • Symbol.for for the RSC marker is the correct choice for cross-module-boundary stability.
  • Defense-in-depth withPhase wrapping on every callback boundary is the right call for ALS.
  • Hard-validate-before-caching in internal_ppr_prerender prevents corrupted cache writes.
  • The three demo pages (comprehensive / static-only / all-dynamic) cover the important edge cases and will be useful as E2E test targets.
  • The html_or_rsc_streaming? predicate cleanly separates legacy streaming behaviors from PPR resume without touching pre-existing code paths.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant